Tech Blog

직접 구현하고 검토를 거친 기술적 선택과 설계를 정리해 남깁니다

태그: 프로그래밍 언어 (1)

static 키워드의 메커니즘 해부 - Java / Kotlin 편

시리즈 집필의 계기 AI를 활용한 코딩이 일상화되면서, 더 이상 특정 언어의 API나 문법에 대한 ‘암기’가 개발자의 핵심 역량이 되진 않는 시대가 되었습니다. 이제는 “어떤 자료구조를 쓰겠다”, “이런 방식으로 구성하겠다”는 정도의 아이디어만 정리하면, AI가 문법과 구현은 대부분 완성해줍니다. 더불어, 언어들 자체도 점차 닮아가고 있습니다. 각 언어가 가진 고유한 문법 차이보다는, 객체지향 프로그래밍(OOP)이라는 공통 패러다임 아래 구조적 유사성이 강해졌습니다. 특히 실무에서 주력으로 사용되는 TypeScript, Java, Kotlin, Python 네 언어는 모던한 문법과 도구 지원을 바탕으로, 빠르게 수렴 중입니다. 이런 맥락에서 이제 중요한 것은 개별 언어의 경험적 숙련이 아니라, 언어를 관통하는 핵심 메커니즘에 대한 깊은 이해입니다. 이 시리즈는 각 언어에서 동일한 개념이 어떻게 구현되고, 어떤 철학을 기반으로 설계되었는지를 분석해보려 합니다. static이란? static은 객체지향 언어를 배울 때 가장 먼저 마주치는 키워드 중 하나입니다. 클래스 수준에서 정의되지만, 클래스의 인스턴스와는 별개로 동작하며, 상태를 공유하거나 유틸리티 메서드를 구현할 때 자주 사용됩니다. 하지만 이 단순해 보이는 키워드는 언어마다 해석이 다르고, 내부적으로 작동하는 방식도 제각각입니다. 어떤 언어는 이를 명시적 키워드로 선언하고, 어떤 언어는 싱글톤 객체나 데코레이터로 대체합니다. 또 어떤 언어는 정적 바인딩을 통해 컴파일 타임에 결정되며, 어떤 언어는 런타임 싱글톤 객체로 처리합니다. 이 글에서는 Java, Kotlin, Python, TypeScript 네 언어를 기준으로 static 키워드가 어떤 방식으로 표현되고 구현되는지, 그리고 그 배경에 깔린 언어 설계 철학은 무엇인지를 깊이 있게 분석해보겠습니다. Java의 static 키워드 Java에서 static 키워드는 클래스 로딩 시점에 JVM의 메서드 영역(Method Area)에 정적으로 할당되는 클래스 수준 멤버를 선언할 때 사용됩니다. 하지만 표면적인 “클래스 멤버”라는 정의만으로 static의 실제 동작을 설명하긴 부족합니다. 실제로는 상속, shadowing(은닉), 정적 초기화, 동적 로딩, 접근 제한 등 다양한 메커니즘이 얽혀 있습니다. 메모리 구조와 static static으로 선언된 필드는 JVM의 Runtime Data Area 중 Method Area에 저장됩니다. 이는 모든 인스턴스가 동일한 메모리 공간을 공유함을 의미합니다. 클래스가 로딩될 때 단 한 번 할당됨 인스턴스가 아닌 클래스 단위로 존재함 공용 상태를 가지므로 멀티스레드 환경에서 동기화 주의 필요 static 초기화 블록과 순서 static 필드 및 static 블록은 클래스 로딩 시점에, 선언 순서대로 실행됩니다. 만약 static 블록 내에서 예외가 발생하면, 해당 클래스는 로딩 자체가 실패하여 NoClassDefFoundError가 발생할 수 있습니다. 관련해서 여러 예시를 알아보겠습니다. x가 초기화되기 위해 printX() 호출 이 시점에 y는 아직 선언되지 않았으므로 0 (원시타입 기본값) JVM의 static 초기화는 선언 순서대로 진행되며, 다른 static 멤버를 참조해도 그 시점에 초기화되지 않았을 수 있음 클래스 로딩시점에, 선언순서대로 실행된다는 말의 의미를 보여주는 예시코드 입니다. System.out.println("Inner static block"); 이 초기화 코드 블럭에 있는 코드가 Inner.class라인에선 실행되지 않다가 정적필드에 접근했을 때 비로소 실행되는 모습을 볼 수 있습니다. static 필드가 첫 접근을 받을 때 (단, final이 아닌 경우) static 메서드 호출 시 Class.forName("...") 사용 시 new 연산자로 인스턴스를 생성할 때 단순히 Inner.class로 로딩하는 것은 초기화 요건에 해당되지 않으니 주의가 필요합니다. static의 상속과 shadowing(은닉) Java에서 static 멤버는 인스턴스가 아닌 클래스에 종속됩니다. 하지만 상속 관계에서 static 필드/메서드가 어떻게 동작하는지는 오해의 소지가 많습니다. static 필드/메서드는 ‘참조 변수의 타입’에 따라 바인딩됩니다. 실제 인스턴스 타입이 아닌 정적 타입(컴파일 타임에 결정) 기준입니다. 이를 static shadowing이라고 부릅니다. 오버라이딩(override)이 아니라, 은닉(hide)일 뿐이며, 인스턴스 메서드와 다르게 동적 바인딩이 전혀 일어나지 않습니다. 정적 import와 네임스페이스 오염 Java 5부터는 import static 구문으로 static 멤버를 직접 import할 수 있습니다. 하지만 static import를 남용하면 이름 충돌(Name Collision)의 위험이 커집니다. 가급적 사용하지 않는걸 권장 드립니다. static 내부 클래스(정적 중첩 클래스)와 외부 참조 Java에서 클래스 안에 선언된 내부 클래스(nested class) 중 static 키워드가 붙은 클래스는 정적 중첩 클래스라고 부릅니다. 일반 내부 클래스는 암묵적으로 외부 클래스의 인스턴스를 참조하고 있습니다. 컴파일 시 외부 클래스의 인스턴스 참조(outerRef)를 자동으로 생성자에 포함시킵니다. 이 경우 GC가 외부 객체를 수거하지 못하는 메모리 누수 위험이 생길 수 있습니다.  반면 static 내부 클래스는 외부 클래스와 완전히 분리된 독립 클래스이므로, 외부 클래스의 인스턴스를 암묵적으로 참조하지 않습니다. 즉, GC의 수거 대상에서 자유롭고, 메모리 해제가 명확하게 관리됩니다. 주로 유틸리티성 도우미 클래스가 외부 클래스의 상태와 무관한 경우  ResponseDto.Success response = ResponseUtil.success(data) Enum, Builder 패턴, DSL 구조 등에서 캡슐화된 논리적 구성 단위로 활용 User user = new User.Builder().name("Astor").build(); VO, DTO 등을 하나의 클래스로 응집하고 싶을 때 AuthInfo.Simple initialize(AuthCommand.Initialize request) 같은 케이스에서 활용할 수 있습니다. Kotlin의 static: Companion Object와 객체지향적 대체 Kotlin은 static 키워드를 제거한 대표적인 JVM 언어입니다. 그렇다면 Kotlin에서 ‘클래스 단위의 멤버’, 즉 Java의 static은 어떻게 구현될까요? Kotlin이 택한 방식은 단순한 문법적 대체가 아니라, JVM 메커니즘 위에 객체지향 설계 철학을 얹은 형태입니다. Companion Object의 메커니즘 Kotlin에서 companion object는 클래스 당 하나만 존재하는 싱글톤 객체입니다. Java의 static처럼 클래스명으로 접근할 수 있지만, 실제로는 클래스 로딩 시 생성된 진짜 객체입니다. Kotlin 컴파일러는 클래스명.필드 또는 클래스명.메서드 형식의 호출을 내부적으로 클래스명.Companion.필드 형태로 변환합니다. 예를 들어 다음 Kotlin 코드를 보겠습니다: Kotlin에서는 Counter.count와 Counter.increment()로 접근하지만, 이 코드는 컴파일 후 JVM에서는 다음과 같이 변환됩니다: 즉 Counter.count는 내부적으로 Counter.Companion.getCount() 호출로 바뀌며, 이는 클래스에 소속된 정적 필드가 아니라 Companion 객체의 멤버에 접근하는 것입니다. 코틀린 파일을 아래처럼 자바에서 직접 호출해보시면 쉽게 이해할 수 있습니다. Java Interop, @JvmStatic, 그리고 진짜 static Kotlin은 static 키워드를 제거한 대신, companion object라는 객체 기반 구조를 통해 Java의 정적 멤버 역할을 대체합니다. 이 방식은 Kotlin 내부에선 매우 일관된 객체지향 구조를 유지해주지만, Java와의 상호 운용성에서는 다소 불편함을 유발할 수 있습니다. 이를 보완하기 위해 Kotlin은 @JvmStatic 어노테이션을 제공합니다. 이 어노테이션은 해당 메서드 또는 프로퍼티를 진짜 static으로 만들어줍니다. 다음과 같이 선언하면: Java에서는 이렇게 바로 사용할 수 있게 됩니다: Companion 역시 여전히 사용가능한걸 볼 수 있는데, @JvmStatic을 붙이면 Counter.Companion.reset()과 Counter.reset() 둘 다 호출 가능해지며, Java 코드 입장에서는 훨씬 깔끔한 API가 됩니다. 상속: 객체이므로 상속과 구현 가능 Kotlin의 companion object는 실제 객체이기 때문에, 클래스처럼 상속하거나 인터페이스를 구현할 수 있습니다. 이는 Java의 static 멤버와는 근본적으로 다른 특징입니다. 여기서 companion object는 Factory 인터페이스를 구현하며, 외부에서 다음과 같이 사용할 수 있습니다: 이처럼 Product 클래스 자체가 Factory로 사용될 수 있는 이유는 companion object가 인터페이스를 구현한 싱글톤 객체이기 때문입니다. 이 구조는 팩토리, 전략, 서비스 로케이터 등 다양한 객체지향 패턴에 응용될 수 있습니다. 다형성: 정적 멤버처럼 보이지만 동적 디스패치 불가 companion object는 객체이긴 하지만, 클래스 간 상속 관계 내에서 다형성(polymorphism)은 지원하지 않습니다. Kotlin은 companion object를 자동으로 상속하지 않기 때문입니다. 이 구조에서 각각 hello를 호출해보면 위와 같은 결과를 알 수 있습니다. 서로 상속 관계지만, 클래스의 companion object는 완전히 독립된 객체입니다. 따라서 Derived의 companion은 Base의 companion을 상속하거나 오버라이딩하지 않으며, 다형적 호출도 불가능합니다. Shadowing: 컴파일 타임 기준의 정적 참조 Kotlin의 companion object 내부 멤버는 정적 바인딩(static binding) 됩니다. 즉, 어떤 클래스명을 통해 호출하느냐에 따라 어떤 메서드가 호출될지가 컴파일 타임에 결정됩니다. 이 때문에 Shadowing(은닉)이 발생합니다. 위의 예시를 통해 코틀린에서 생길 수 있는 companion object의 은닉 케이스들을 모두 확인하실 수 있습니다. object 키워드 Kotlin은 static 키워드를 제거하면서도, 정적 멤버 또는 전역 객체처럼 사용 가능한 구조를 제공하기 위해 object 키워드를 도입했습니다. object는 "단 하나만 존재하는 객체(Singleton)"를 선언하는 구문입니다. 클래스의 인스턴스를 생성하지 않고도, 상태와 동작을 담은 객체를 바로 정의하고 사용할 수 있습니다. 이 객체는 다음과 같이 바로 사용할 수 있습니다: Java에서 static 메서드로 구성되던 유틸리티 클래스를 Kotlin에서는 object로 선언 등 활용할 수 있습니다.  Top-level 함수의 정적화 코틀린은 파일 수준(최상위)의 함수 및 변수를 통해 정적 멤버처럼 사용할 수 있는 또 다른 구조를 제공합니다. 이것이 바로 Top-level 함수와 프로퍼티입니다. 클래스나 객체 내부가 아닌, 파일 자체의 루트 레벨에 선언된 함수를 Kotlin에서는 Top-level 함수라고 부릅니다. 이 함수는 Kotlin에서는 별다른 클래스나 객체 없이 이렇게 호출됩니다: Kotlin 내부에서는 매우 자연스럽고 간결한 표현 방식입니다. 하지만 이 함수는 실제로 JVM에선 어떻게 표현될까요? JVM에서의 정적 함수 변환 방식 Top-level 함수는 Kotlin 컴파일러에 의해 JVM의 정적(static) 메서드로 변환됩니다. 위의 MathUtils.kt 파일을 Java로 디컴파일하면 다음과 같은 클래스가 자동 생성됩니다: 즉, Kotlin의 Top-level 함수는 실제로는 정적 메서드이며, 클래스명은 \[파일명 + Kt] 형식으로 자동 생성됩니다. Top-level 함수는 Kotlin의 파일 지향 문법 구조 덕분에 가능한 방식입니다. Kotlin은 파일 단위로 컴파일 단위를 만들고, 파일 내 정의된 함수들을 자동으로 정적 메서드로 컴파일합니다.


static 키워드의 메커니즘 해부 - Java / Kotlin 편
OOP
Java
+2